/* * Copyright 2011-2012 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.shell.core; import static org.fusesource.jansi.Ansi.ansi; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileWriter; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import java.nio.charset.Charset; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import jline.WindowsTerminal; import jline.console.ConsoleReader; import jline.console.history.History; import jline.console.history.MemoryHistory; import org.apache.commons.io.input.ReversedLinesFileReader; import org.fusesource.jansi.Ansi; import org.fusesource.jansi.Ansi.Attribute; import org.fusesource.jansi.Ansi.Color; import org.fusesource.jansi.Ansi.Erase; import org.fusesource.jansi.AnsiConsole; import org.springframework.shell.event.ShellStatus; import org.springframework.shell.event.ShellStatus.Status; import org.springframework.shell.event.ShellStatusListener; import org.springframework.shell.support.util.IOUtils; import org.springframework.shell.support.util.OsUtils; import org.springframework.shell.support.util.VersionUtils; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** * Uses the feature-rich <a href="http://sourceforge.net/projects/jline/">JLine</a> library to provide an interactive * shell. * * <p> * Due to Windows' lack of color ANSI services out-of-the-box, this implementation automatically detects the classpath * presence of <a href="http://jansi.fusesource.org/">Jansi</a> and uses it if present. This library is not necessary * for *nix machines, which support colour ANSI without any special effort. This implementation has been written to use * reflection in order to avoid hard dependencies on Jansi. * * @author Ben Alex * @author Jarred Li * @author Glenn Renfro * @since 1.0 */ public abstract class JLineShell extends AbstractShell implements Shell, Runnable { // Constants private static final String ANSI_CONSOLE_CLASSNAME = "org.fusesource.jansi.AnsiConsole"; private static final boolean JANSI_AVAILABLE = ClassUtils.isPresent(ANSI_CONSOLE_CLASSNAME, JLineShell.class.getClassLoader()); private static final char ESCAPE = 27; private static final String BEL = "\007"; // Fields protected ConsoleReader reader; private boolean developmentMode = false; private FileWriter fileLog; private final DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); protected ShellStatusListener statusListener; // ROO-836 /** key: slot name, value: flashInfo instance */ private final Map<String, FlashInfo> flashInfoMap = new HashMap<String, FlashInfo>(); /** key: row number, value: eraseLineFromPosition */ private final Map<Integer, Integer> rowErasureMap = new HashMap<Integer, Integer>(); private boolean shutdownHookFired = false; // ROO-1599 private int historySize; public void run() { reader = createConsoleReader(); setPromptPath(null); JLineLogHandler handler = new JLineLogHandler(reader, this); JLineLogHandler.prohibitRedraw(); // Affects this thread only Logger mainLogger = Logger.getLogger(""); removeHandlers(mainLogger); mainLogger.addHandler(handler); reader.addCompleter(new ParserCompleter(getParser())); reader.setBellEnabled(true); if (Boolean.getBoolean("jline.nobell")) { reader.setBellEnabled(false); } // reader.setDebug(new PrintWriter(new FileWriter("writer.debug", true))); openFileLogIfPossible(); History history = this.reader.getHistory(); if (history instanceof MemoryHistory) { ((MemoryHistory) history).setMaxSize(getHistorySize()); } // Try to build previous command history from the project's log String[] filteredLogEntries = filterLogEntry(); for (String logEntry : filteredLogEntries) { reader.getHistory().add(logEntry); } flashMessageRenderer(); flash(Level.FINE, this.getProductName() + " " + this.getVersion(), Shell.WINDOW_TITLE_SLOT); printBannerAndWelcome(); String startupNotifications = getStartupNotifications(); if (StringUtils.hasText(startupNotifications)) { logger.info(startupNotifications); } setShellStatus(Status.STARTED); try { // Monitor CTRL+C initiated shutdowns (ROO-1599) Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { public void run() { shutdownHookFired = true; } }, getProductName() + " JLine Shutdown Hook")); } catch (Throwable t) { } // Handle any "execute-then-quit" operation String rooArgs = System.getProperty("roo.args"); if (rooArgs != null && !"".equals(rooArgs)) { setShellStatus(Status.USER_INPUT); boolean success = executeCommand(rooArgs).isSuccess(); if (exitShellRequest == null) { // The command itself did not specify an exit shell code, so we'll fall back to something sensible here executeCommand("quit"); // ROO-839 exitShellRequest = success ? ExitShellRequest.NORMAL_EXIT : ExitShellRequest.FATAL_EXIT; } setShellStatus(Status.SHUTTING_DOWN); } else { // Normal RPEL processing promptLoop(); } } /** * read history commands from history log. the history size if determined by --histsize options. * * @return history commands */ private String[] filterLogEntry() { ArrayList<String> entries = new ArrayList<String>(); ReversedLinesFileReader reversedReader = null; try { reversedReader = new ReversedLinesFileReader(new File(getHistoryFileName()), 4096, Charset.forName("UTF-8")); int size = 0; String line = null; while ((line = reversedReader.readLine()) != null) { if (!line.startsWith("//")) { size++; if (size > historySize) { break; } else { entries.add(line); } } } } catch (IOException e) { logger.warning("read history file failed. Reason:" + e.getMessage()); } finally { closeReversedReader(reversedReader); } Collections.reverse(entries); return entries.toArray(new String[0]); } private void closeReversedReader(ReversedLinesFileReader reversedReader) { if (reversedReader != null) { try { reversedReader.close(); } catch (IOException ex) { logger.warning("Cloud not close ReversedLinesFileReader: " + ex); } } } /** * Creates new jline ConsoleReader. On Windows if jansi is available, uses createAnsiWindowsReader(). Otherwise, * always creates a default ConsoleReader. Sub-classes of this class can plug in their version of ConsoleReader by * overriding this method, if required. * * @return a jline ConsoleReader instance */ protected ConsoleReader createConsoleReader() { ConsoleReader consoleReader = null; try { if (isJansiAvailable()) { try { consoleReader = createAnsiWindowsReader(); } catch (Exception e) { // Try again using default ConsoleReader constructor logger.warning("Can't initialize jansi AnsiConsole, falling back to default: " + e); } } if (consoleReader == null) { consoleReader = new ConsoleReader(); } } catch (IOException ioe) { throw new IllegalStateException("Cannot start console class", ioe); } consoleReader.setExpandEvents(false); return consoleReader; } private boolean isJansiAvailable() { return JANSI_AVAILABLE && OsUtils.isWindows() && System.getProperty("jline.terminal") == null; } public void printBannerAndWelcome() { } public String getStartupNotifications() { return null; } private void removeHandlers(final Logger l) { Handler[] handlers = l.getHandlers(); if (handlers != null && handlers.length > 0) { for (Handler h : handlers) { l.removeHandler(h); } } } @Override public void setPromptPath(final String path) { setPromptPath(path, false); } @Override public void setPromptPath(final String path, final boolean overrideStyle) { if (reader.getTerminal().isAnsiSupported()) { // ANSIBuffer ansi = JLineLogHandler.getANSIBuffer(); Ansi ansi = ansi(); if (path == null || "".equals(path)) { shellPrompt = ansi.fg(Color.YELLOW).a(getPromptText()).reset().toString(); } else { if (overrideStyle) { ansi.a(path); } else { ansi.fg(Color.CYAN).a(path).reset(); } shellPrompt = ansi.fg(Color.YELLOW).a(" " + getPromptText()).toString(); } } else { // The superclass will do for this non-ANSI terminal super.setPromptPath(path); } // The shellPrompt is now correct; let's ensure it now gets used reader.setPrompt(AbstractShell.shellPrompt); } protected ConsoleReader createAnsiWindowsReader() throws Exception { // Get decorated OutputStream that parses ANSI-codes final PrintStream ansiOut = (PrintStream) ClassUtils .forName(ANSI_CONSOLE_CLASSNAME, JLineShell.class.getClassLoader()).getMethod("out").invoke(null); WindowsTerminal ansiTerminal = new WindowsTerminal() { @Override public synchronized boolean isAnsiSupported() { return true; } }; ansiTerminal.init(); // Make sure to reset the original shell's colors on shutdown by closing the stream statusListener = new ShellStatusListener() { public void onShellStatusChange(final ShellStatus oldStatus, final ShellStatus newStatus) { if (newStatus.getStatus().equals(Status.SHUTTING_DOWN)) { ansiOut.close(); } } }; addShellStatusListener(statusListener); // return new ConsoleReader(new FileInputStream(FileDescriptor.in), new PrintWriter(new OutputStreamWriter( // ansiOut, // // Default to Cp850 encoding for Windows console output (ROO-439) // System.getProperty("jline.WindowsTerminal.output.encoding", "Cp850"))), null, ansiTerminal); OutputStream out = AnsiConsole.wrapOutputStream(ansiOut); return new ConsoleReader(new FileInputStream(FileDescriptor.in), out, ansiTerminal); } private void flashMessageRenderer() { if (!reader.getTerminal().isAnsiSupported()) { return; } // Setup a thread to ensure flash messages are displayed and cleared correctly Thread t = new Thread(new Runnable() { public void run() { while (!shellStatus.getStatus().equals(Status.SHUTTING_DOWN) && !shutdownHookFired) { synchronized (flashInfoMap) { long now = System.currentTimeMillis(); Set<String> toRemove = new HashSet<String>(); for (String slot : flashInfoMap.keySet()) { FlashInfo flashInfo = flashInfoMap.get(slot); if (flashInfo.flashMessageUntil < now) { // Message has expired, so clear it toRemove.add(slot); doAnsiFlash(flashInfo.rowNumber, Level.ALL, ""); } else { // The expiration time for this message has not been reached, so preserve it doAnsiFlash(flashInfo.rowNumber, flashInfo.flashLevel, flashInfo.flashMessage); } } for (String slot : toRemove) { flashInfoMap.remove(slot); } } try { Thread.sleep(200); } catch (InterruptedException ignore) { } } } }, getProductName() + " JLine Flash Message Manager"); t.start(); } @Override public void flash(final Level level, final String message, final String slot) { Assert.notNull(level, "Level is required for a flash message"); Assert.notNull(message, "Message is required for a flash message"); Assert.hasText(slot, "Slot name must be specified for a flash message"); if (Shell.WINDOW_TITLE_SLOT.equals(slot)) { if (reader != null && reader.getTerminal().isAnsiSupported()) { // We can probably update the window title, as requested if (!StringUtils.hasText(message)) { System.out.println("No text"); } Ansi ansi = ansi(); ansi.a(ESCAPE + "]0;").a(message).a(BEL); try { reader.print(ansi.toString()); reader.flush(); } catch (IOException ignored) { } } return; } if ((reader != null && !reader.getTerminal().isAnsiSupported())) { super.flash(level, message, slot); return; } synchronized (flashInfoMap) { FlashInfo flashInfo = flashInfoMap.get(slot); if ("".equals(message)) { // Request to clear the message, but give the user some time to read it first if (flashInfo == null) { // We didn't have a record of displaying it in the first place, so just quit return; } flashInfo.flashMessageUntil = System.currentTimeMillis() + 1500; } else { // Display this message displayed until further notice if (flashInfo == null) { // Find a row for this new slot; we basically take the first line number we discover flashInfo = new FlashInfo(); flashInfo.rowNumber = Integer.MAX_VALUE; outer: for (int i = 1; i < Integer.MAX_VALUE; i++) { for (FlashInfo existingFlashInfo : flashInfoMap.values()) { if (existingFlashInfo.rowNumber == i) { // Veto, let's try the new candidate row number continue outer; } } // If we got to here, nobody owns this row number, so use it flashInfo.rowNumber = i; break outer; } // Store it flashInfoMap.put(slot, flashInfo); } // Populate the instance with the latest data flashInfo.flashMessageUntil = Long.MAX_VALUE; flashInfo.flashLevel = level; flashInfo.flashMessage = message; // Display right now doAnsiFlash(flashInfo.rowNumber, flashInfo.flashLevel, flashInfo.flashMessage); } } } // Externally synchronized via the two calling methods having a mutex on flashInfoMap private void doAnsiFlash(final int row, final Level level, final String message) { Ansi ansi = ansi(); if (isAppleTerminal()) { ansi.a(ESCAPE + "7"); } else { ansi.saveCursorPosition(); } // Figure out the longest line we're presently displaying (or were) and erase the line from that position int mostFurtherLeftColNumber = Integer.MAX_VALUE; for (Integer candidate : rowErasureMap.values()) { if (candidate < mostFurtherLeftColNumber) { mostFurtherLeftColNumber = candidate; } } if (mostFurtherLeftColNumber == Integer.MAX_VALUE) { // There is nothing to erase } else { ansi.cursor(row, mostFurtherLeftColNumber); ansi.eraseLine(Erase.FORWARD); // Clear what was present on the line } if (("".equals(message))) { // They want the line blank; we've already achieved this if needed via the erasing above // Just need to record we no longer care about this line the next time doAnsiFlash is invoked rowErasureMap.remove(row); } else { if (shutdownHookFired) { return; // ROO-1599 } // They want some message displayed int startFrom = reader.getTerminal().getWidth() - message.length() + 1; if (startFrom < 1) { startFrom = 1; } ansi.cursor(row, startFrom); ansi.a(Attribute.NEGATIVE_ON).a(message).a(Attribute.NEGATIVE_OFF); // Record we want to erase from this positioning next time (so we clean up after ourselves) rowErasureMap.put(row, startFrom); } if (isAppleTerminal()) { ansi.a(ESCAPE + "8"); } else { ansi.restorCursorPosition(); } try { reader.print(ansi.toString()); reader.flush(); } catch (IOException ignored) { } } /** * Awaits user input, executes the command and displays the prompt to the user. */ public void promptLoop() { setShellStatus(Status.USER_INPUT); String line; String prompt = getPromptText(); try { while (exitShellRequest == null && (reader != null && ((line = reader.readLine()) != null))) { JLineLogHandler.resetMessageTracking(); setShellStatus(Status.USER_INPUT); if (!StringUtils.hasText(line)) { //generate prompt if empty line, the prompt maybe showing the time or something else that updates //independent of the lack of a command to execute. prompt = generatePromptUpdate(prompt); continue; } executeCommand(line); //update the prompt after the command has been executed in case an application event listener in the //command changes state in the prompt provider. prompt = generatePromptUpdate(prompt); } } catch (IOException ioe) { throw new IllegalStateException("Shell line reading failure", ioe); } setShellStatus(Status.SHUTTING_DOWN); } /** * Retrieves the latest prompt and if the latest prompt is different than the existing prompt, * the shellPrompt is updated. * @param existingPrompt The prompt that is recognized as the current prompt. * @return The prompt that the shellPrompt displays. */ public String generatePromptUpdate(String existingPrompt) { String newPrompt = getPromptText(); if (!ObjectUtils.nullSafeEquals(existingPrompt, newPrompt)) { setPromptPath(null); } return newPrompt; } public void setDevelopmentMode(final boolean developmentMode) { JLineLogHandler.setIncludeThreadName(developmentMode); JLineLogHandler.setSuppressDuplicateMessages(!developmentMode); // We want to see duplicate messages during // development time (ROO-1873) this.developmentMode = developmentMode; } public boolean isDevelopmentMode() { return this.developmentMode; } private void openFileLogIfPossible() { try { fileLog = new FileWriter(getHistoryFileName(), true); // First write, so let's record the date and time of the first user command fileLog.write("// " + getProductName() + " " + versionInfo() + " log opened at " + df.format(new Date()) + "\n"); fileLog.flush(); } catch (IOException ignoreIt) { } } @Override protected void logCommandToOutput(final String processedLine) { if (fileLog == null) { openFileLogIfPossible(); if (fileLog == null) { // Still failing, so give up return; } } try { fileLog.write(processedLine + "\n"); // Unix line endings only from Roo fileLog.flush(); // So tail -f will show it's working if (getExitShellRequest() != null) { // Shutting down, so close our file (we can always reopen it later if needed) fileLog.write("// " + getProductName() + " " + versionInfo() + " log closed at " + df.format(new Date()) + "\n"); IOUtils.closeQuietly(fileLog); fileLog = null; } } catch (IOException ignoreIt) { } } /** * Obtains the "roo.home" from the system property, falling back to the current working directory if missing. * * @return the 'roo.home' system property */ @Override protected String getHomeAsString() { String rooHome = System.getProperty("roo.home"); if (rooHome == null) { try { rooHome = new File(".").getCanonicalPath(); } catch (Exception e) { throw new IllegalStateException(e); } } return rooHome; } /** * Should be called by a subclass before deactivating the shell. */ protected void closeShell() { // Notify we're closing down (normally our status is already shutting_down, but if it was a CTRL+C via the // o.s.r.bootstrap.Main hook) setShellStatus(Status.SHUTTING_DOWN); if (statusListener != null) { removeShellStatusListener(statusListener); } } private static class FlashInfo { String flashMessage; long flashMessageUntil; Level flashLevel; int rowNumber; } /** * get history file name from provider. The provider has highest order * <link>org.springframework.core.Ordered.getOder</link> will win. * * @return history file name */ abstract protected String getHistoryFileName(); /** * get prompt text from provider. The provider has highest order * <link>org.springframework.core.Ordered.getOder</link> will win. * * @return prompt text */ abstract protected String getPromptText(); /** * get product name * * @return Product Name */ abstract protected String getProductName(); /** * get version information * * @return Version */ protected String getVersion() { return VersionUtils.versionInfo(); } /** * @return the historySize */ public int getHistorySize() { return historySize; } /** * @param historySize the historySize to set */ public void setHistorySize(int historySize) { this.historySize = historySize; } private static boolean isAppleTerminal() { final String terminalName = System.getenv("TERM_PROGRAM"); return ("Apple_Terminal".equalsIgnoreCase(terminalName) || Boolean.getBoolean("is.apple.terminal")); } }